03 设计模式——原型模式

返回设计模式博客目录

介绍


原型模式:用原型实例指定创建对象的种类,并通过复制这些原型创建新的对象

原型模式是一个创建型的模式。该模式应该有一个样板实例,用户从这个样板对象中复制出一个内部属性一致的对象,这个过程也就是我们俗称的“克隆”。被复制的实例就是我们所称的“原型”,这个原型是可定制的。原型模式多用于创建复杂的或者构造耗时的实例,因为这种情况下,复制一个已经存在的实例可使程序运行更高效。

使用场景

  • 类初始化需要消耗非常多的资源,这个资源包括数据、硬件资源等,通过原型复制避免这些消耗。
  • 通过 new 产生一个对象需要非常繁琐的数据准备或访问权限,这时可以使用原型模式。
  • 一个对象需要提供给其他对象访问,而且各个调用者可能都需要修改其值时,可以考虑使用原型模式复制多个对象供调用者使用,即保护性拷贝。

需要注意的是,通过实现 Cloneable 接口的原型模式在调用 clone 函数构造实例时并不一定比通过 new 操作速度快,只有当通过 new 构造对象较为耗时或者成本高时,通过 clone 方法才能够获得效率上的提升。因此,在使用 Cloneable 接口时需要考虑构建对象的成本以及做一些效率上的测试。当然,实现原型模式不一定非要实现 Cloneable 接口,也有其他的实现方式,见后文。

UML 类图

  • Client:客户端用户。
  • Prototype:抽象类或者接口,声明具备 clone 能力。
  • ConcretePrototype:具体的原型类。

示例:文档拷贝


在这个例子中,首先创建了一个文档对象,即 WordDocument,这个文档中含有文字和图片。用户经过了长时间的编辑后,打算对该文档做进一步的编辑。但是,这个编辑后的文档是否会被采用还不确定。因此,为了安全起见,用户需要将当前文档拷贝一份,然后再在文档副本上进行修改。如此,这个原始文档就是我们上述所说的样板实例,也就是将要被“克隆”的对象,我们称为“原型”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public class WordDocument implements Cloneable {
// 文本
private String text;
// 图片
private ArrayList<String> images = new ArrayList<>();
public WordDocument() {
System.out.println("---WordDocument 构造函数---");
}
@Override
protected WordDocument clone() {
try {
WordDocument document = (WordDocument) super.clone();
document.text = this.text;
document.images = this.images;
return document;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
public String getText() {
return text;
}
public void setText(String text) {
this.text = text;
}
public ArrayList<String> getImages() {
return images;
}
public void addImage(String img) {
images.add(img);
}
/**
* 打印文档内容
*/
public void showDocument() {
System.out.println("---Word content start---");
System.out.println("Text : " + text);
System.out.println("images list : ");
for (String name : images) {
System.out.println("image name : " + name);
}
System.out.println("---Word content end---");
}
}

通过 WordDocument 类模拟了 Word 文档中的基本元素,即文字和图片。WordDocument 在该原型模式示例中扮演的角色为 ConcretePrototype,而 Cloneable 的角色则为 Prototype。WordDocument 中的 clone 方法用以实现对象克隆。注意,这个方法并不是 Cloneable 接口的,而是 Object 中的方法。Cloneable 也是一个标识接口,它表明这个类的对象是可拷贝的。如果没有实现 Cloneable 接口却调用了 clone() 函数将抛出异常。在这个示例中,我们通过实现 Cloneable 接口和覆写 clone 方法实现原型模式。

下面看看 Client 端的调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
WordDocument originDoc = new WordDocument();
originDoc.setText("这是一篇文档");
originDoc.addImage("图片1");
originDoc.addImage("图片2");
originDoc.addImage("图片3");
originDoc.showDocument();
WordDocument doc2 = originDoc.clone();
doc2.showDocument();
doc2.setText("这是修改过的文档");
doc2.addImage("图片4");
doc2.showDocument();
originDoc.showDocument();

输出结果如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
---WordDocument 构造函数---
---Word content start---
Text : 这是一篇文档
images list :
image name : 图片1
image name : 图片2
image name : 图片3
---Word content end---
---Word content start---
Text : 这是一篇文档
images list :
image name : 图片1
image name : 图片2
image name : 图片3
---Word content end---
---Word content start---
Text : 这是修改过的文档
images list :
image name : 图片1
image name : 图片2
image name : 图片3
image name : 图片4
---Word content end---
---Word content start---
Text : 这是一篇文档
images list :
image name : 图片1
image name : 图片2
image name : 图片3
image name : 图片4
---Word content end---

从上面可以看到,doc2 是通过 originDoc.clone() 创建的,并且 doc2 第一次输出的时候和 originDoc 输出是一样的,即 doc2 是 originDoc 的一份拷贝,它们的内容是一样的,而 doc2 修改了文本内容以后并不影响 originDoc 的文本内容,这就保证了 originDoc 的安全性。还需要注意的是,通过 clone 拷贝对象时并不会执行构造函数。

但是,originDoc 的图片列表内容(images)被更改了,这是为什么呢?因为上述示例只是一个浅拷贝

浅拷贝和深拷贝


  • 浅拷贝又叫影子拷贝,上面我们在拷贝文档时并没有把原文档中的字段都重新构造了一遍,而只是拷贝了引用,也就是副文档的字段引用原始文档的字段,这样的话修改副文档中的内容就会连原始文档也改掉了,这就是浅拷贝。
  • 深拷贝就是在浅拷贝的基础上,对于引用类型的字段也要采用拷贝的形式,比如上面的 images,而像 String、int 这些基本数据类型则没关系

所以在运用原型模式时建议大家还是用深拷贝。下面我们把上面的浅拷贝改成深拷贝,clone 方法修改如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
protected WordDocument clone() {
try {
WordDocument document = (WordDocument) super.clone();
document.text = this.text;
// document.images = this.images;
// 对 images 对象也调用 clone() 函数,进行深拷贝
document.images = (ArrayList<String>) this.images.clone();
return document;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}

ANDROID 源码中的原型模式


在 Android 中,Intent 可能是我们最早接触的几个类型之一,它用于跳转 Activity、启动服务、发布广播等功能,它是 Android 系统各组件之间的纽带,也是组件之间传递数据的载体,正式 Intent 的存在才使得 Android 各个组件之间的耦合性很低,Android 的组件才如此灵活。

下面以 Intent 来分析源码中的原型模式,首先看如下示例。

1
2
3
4
5
6
Uri uri = Uri.parse("smsto:0800000123");
Intent shareIntent = new Intent(Intent.ACTION_SENDTO, uri);
shareIntent.putExtra("sms_body", "The SMS text");
// 克隆副本
Intent intent = (Intent) shareIntent.clone();
startActivity(intent);

通过 shareIntent.clone 方法拷贝了一个对象 Intent,然后执行 startActivity(intent),随即就进入了短信页面,号码为 0800000123,文本内容为 The SMS text,即这些内容都与 shareIntent 一致。

我们看看 Intent 的 clone() 方法是如何实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public class Intent implements Parcelable, Cloneable {
@Override
public Object clone() {
return new Intent(this);
}
/**
* Copy constructor.
*/
public Intent(Intent o) {
this(o, COPY_MODE_ALL);
}
private Intent(Intent o, @CopyMode int copyMode) {
this.mAction = o.mAction;
this.mData = o.mData;
this.mType = o.mType;
this.mPackage = o.mPackage;
this.mComponent = o.mComponent;
if (o.mCategories != null) {
this.mCategories = new ArraySet<>(o.mCategories);
}
if (copyMode != COPY_MODE_FILTER) {
this.mFlags = o.mFlags;
this.mContentUserHint = o.mContentUserHint;
this.mLaunchToken = o.mLaunchToken;
if (o.mSourceBounds != null) {
this.mSourceBounds = new Rect(o.mSourceBounds);
}
if (o.mSelector != null) {
this.mSelector = new Intent(o.mSelector);
}
if (copyMode != COPY_MODE_HISTORY) {
if (o.mExtras != null) {
this.mExtras = new Bundle(o.mExtras);
}
if (o.mClipData != null) {
this.mClipData = new ClipData(o.mClipData);
}
} else {
if (o.mExtras != null && !o.mExtras.maybeIsEmpty()) {
this.mExtras = Bundle.STRIPPED;
}
}
}
}
// 代码省略
}

clone 方法并没有调用 super.clone() 来实现对象拷贝,而是调用了 new Intent(this)。在上文中我们提到过,使用 clone 和 new 需要根据构造对象的成本来决定。如果对象的构造成本比较高或者构造较为麻烦,那么使用 clone() 函数效率较高,否则可以使用 new 的形式。

原型模式实战


在开发中,我们有时候会满足一些需求,就是有的对象的内容只允许客户端程序读取,而不允许修改,比如用户登录信息。我们通常会用 LoginSession 保存用户的登录信息,这些用户信息可能在 APP 的其他模块被用来做登录校验、用户个人信息显示等。但是,这些信息在客户端程序是不允许修改的。此时,就需要使用原型模式来进行保护性拷贝。表现形式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
// 用户实体类
public class User implements Cloneable {
private int age;
private String name;
private String phoneNum;
private Address address;
@Override
protected Object clone() {
User user = null;
try {
user = (User) super.clone();
// 深拷贝
user.address = (Address) this.address.clone();
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}
return user;
}
// setter & getter
}
// 用户地址类
public class Address implements Cloneable {
private String city;
private String district;
private String street;
@Override
protected Object clone() {
Address address = new Address();
address.city = this.city;
address.district = this.district;
address.street = this.street;
return address;
}
// setter & getter
}
// 登录接口
public interface ILogin {
void login();
}
// 登录实现
public class LoginImpl implements ILogin {
@Override
public void login() {
User loggedUser = new User();
// 获取用户信息
// ...
// 成功登录后,将用户信息保存到 Session 中
LoginSession.getInstance().setLoggedUser(loggedUser);
}
}
// 登录 Session
public class LoginSession {
// 已登录用户信息
private User loggedUser;
private LoginSession() {}
private static class LoginSessionHolder {
private static final LoginSession sLoginSession = new LoginSession();
}
public static LoginSession getInstance() {
return LoginSessionHolder.sLoginSession;
}
// 设置已登录的用户信息,不对外开放
void setLoggedUser(User user) {
loggedUser = user;
}
public User getLoggedUser() {
if (null == loggedUser) return null;
return (User)loggedUser.clone();
}
}

这就使得在任何地方调用 getLoggedUser 函数获取到的用户对象都是一个拷贝对象,即使客户端代码一不小心修改了这个拷贝对象,也不会影响最初的已登录用户对象,对已登录用户信息的修改只能通过 setLoggedUser 这个方法,而只有与 LoginSession 在同一个包下的类才能访问这个包级私有方法。